52 — 为场景添加细节

Three.js Journey · Bruno Simon · 难度:简单

52 — 为场景添加细节 课程海报

简介

有了烘焙纹理,我们的场景看起来很棒,但我们还没有真正发挥出 WebGL 的全部潜力。让我们添加更多细节,让场景更有生气。

首先,我们要添加一些四处漂浮的萤火虫。然后,我们会在传送门内部制作一段很酷的动画。但首先,我们需要改变那个黑色的背景。

设置

代码与上一课结束时完全一致。

我们的场景已经导入到 Three.js 中,可以用 OrbitControls 环绕旋转,并且我们的 lil-gui 实例也已经准备好了。

背景颜色

对于背景,我们不打算做得太花哨。我们只是把颜色换成与场景更匹配一点的颜色。为了找到完美的颜色,我们要给 lil-gui 添加一个 tweak。

在创建 lil-gui 实例之前,添加一个 debugObject:

// Debug
const debugObject = {}
const gui = new GUI({
    width: 400
})

和前面几课一样,这个对象的作用只是把颜色作为一个属性存储起来,以便我们把它作为 tweak 添加到 lil-gui。

在实例化 renderer 之后,向 debugObject 对象添加一个 clearColor 属性:

// Clear color
debugObject.clearColor = '#ff0000'

然后用 gui.addColor(...) 把它添加到 lil-gui 的 tweak 中:

gui.addColor(debugObject, 'clearColor')

你应该能看到颜色 tweak,但我们还没有使用它。

使用该值通过 renderer.setClearColor(...) 来更改背景颜色:

debugObject.clearColor = '#ff0000'
renderer.setClearColor(debugObject.clearColor)
gui.addColor(debugObject, 'clearColor')

遗憾的是,修改 tweak 并不会改变背景的颜色,因为我们只是改变了 clearColor 属性。

当 tweak 变化时,我们需要再次调用 renderer.setClearColor(...):

gui
    .addColor(debugObject, 'clearColor')
    .onChange(() =>
    {
        renderer.setClearColor(debugObject.clearColor)
    })
lesson demo video preview

现在你可以找出与你的场景最匹配的颜色了:

debugObject.clearColor = '#201919'

萤火虫

为了创建萤火虫,我们要使用带有自定义几何体的粒子。

基础

首先创建一个空的 BufferGeometry:

/**
 * Fireflies
 */
// Geometry
const firefliesGeometry = new THREE.BufferGeometry()

创建一个名为 position 的 BufferAttribute 来设置每个粒子的位置:

const firefliesGeometry = new THREE.BufferGeometry()
const firefliesCount = 30
const positionArray = new Float32Array(firefliesCount * 3)

for(let i = 0; i < firefliesCount; i++)
{
    positionArray[i * 3 + 0] = Math.random() * 4
    positionArray[i * 3 + 1] = Math.random() * 4
    positionArray[i * 3 + 2] = Math.random() * 4
}

firefliesGeometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3))

萤火虫的位置是随机的,但等我们能看到萤火虫之后,会再改进它们的位置。

为了能看到萤火虫,我们要使用一个 PointsMaterial,但稍后我们会把它换成一个自定义的 ShaderMaterial:

创建一个 PointsMaterial,size 为 0.1,并把 sizeAttenuation 设为 true:

// Material
const firefliesMaterial = new THREE.PointsMaterial({ size: 0.1, sizeAttenuation: true })

最后,使用 firefliesGeometry 和 firefliesMaterial 创建 Points 实例,并把它添加到场景中:

// Points
const fireflies = new THREE.Points(firefliesGeometry, firefliesMaterial)
scene.add(fireflies)
lesson demo video preview

你应该会看到一些很大的方形粒子。

让我们更改位置,使其适合场景:

for(let i = 0; i < firefliesCount; i++)
{
    positionArray[i * 3 + 0] = (Math.random() - 0.5) * 4
    positionArray[i * 3 + 1] = Math.random() * 1.5
    positionArray[i * 3 + 2] = (Math.random() - 0.5) * 4
}

粒子在 x 和 z 轴上不应该离得太远,并且在 y 轴上应保持在地面之上。

自定义着色器材质

现在我们可以把材质换成 ShaderMaterial,以便让它们动起来,同时也绘制出比那些丑陋方形更好看的东西。

你可以试着自己编写这些 shader。

以下是 /src/shaders/fireflies/vertex.glsl 的代码:

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectionPosition = projectionMatrix * viewPosition;

    gl_Position = projectionPosition;
    gl_PointSize = 40.0;
}

这段代码没有做任何特别的事情。通过应用各种矩阵,顶点被放置到它应该在的位置。我们还添加了 gl_PointSize 来控制粒子的大小。

以下是 /src/shaders/fireflies/fragment.glsl 的代码:

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

与 vertex shader 一样,这段代码没有做任何特别的事情,粒子应该显示为红色。

现在我们可以把这些 shader 导入到脚本中:

import firefliesVertexShader from './shaders/fireflies/vertex.glsl'
import firefliesFragmentShader from './shaders/fireflies/fragment.glsl'

加载 .glsl 文件的规则已经添加到 Vite 配置中。firefliesVertexShader 和 firefliesFragmentShader 应该包含我们编写的两个 shader。

把 PointsMaterial 替换为一个带有 vertexShader 和 fragmentShader 属性的 ShaderMaterial:

const firefliesMaterial = new THREE.ShaderMaterial({
    vertexShader: firefliesVertexShader,
    fragmentShader: firefliesFragmentShader
})

你应该会得到红色方块作为萤火虫。

点的大小

那些方块在你的屏幕上可能看起来更大,这是由像素比(pixel ratio)造成的。在我们的 vertex shader 中,我们写了 gl_PointSize = 40.0;,这意味着每个粒子在渲染中会占据 40 像素的宽度和高度,但如果你使用的是高像素比的屏幕,40 像素会显得更小,因为同样的空间里有更多像素。

为了解决这个问题,我们可以把像素比作为一个 uniform 传进去:

// Material
const firefliesMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) }
    },
    vertexShader: firefliesVertexShader,
    fragmentShader: firefliesFragmentShader
})

我们没有使用 renderer.getPixelRatio(),因为 renderer 是在后面才声明的。这样做会导致错误。但如果你愿意,可以把整个萤火虫代码移到 renderer 之后。

现在我们可以在 vertex shader 中获取 uPixelRatio uniform,并用它乘以我们的 gl_PointSize:

uniform float uPixelRatio;

void main()
{
    // ...

    gl_PointSize = 40.0 * uPixelRatio;
}

现在无论屏幕的像素比如何,你都应该看到相同的粒子大小。

如果用户把窗口从一个屏幕移到另一个像素比不同的屏幕上,可能会出现问题。幸运的是,大多数情况下,用户这样做时也会调整窗口大小,让它更合适。因此,我们只需在 resize 回调函数中更新 uniform 即可:

window.addEventListener('resize', () =>
{
    // ...

    // Update fireflies
    firefliesMaterial.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2)
})

我们的粒子还有另一个问题:无论距离远近,方块的大小都一样。我们需要启用尺寸衰减(size attenuation),可以用以下代码来实现:

// ...

void main()
{
    // ...
    
    gl_PointSize = 40.0 * uPixelRatio;
    gl_PointSize *= (1.0 / - viewPosition.z);
}
lesson demo video preview

现在粒子的大小应该取决于到摄像机的距离。

最后,我们可以创建一个 uSize uniform 来控制粒子的大小:

const firefliesMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) },
        uSize: { value: 100 }
    },
    // ...
})

在 shader 中使用它:

// ...
uniform float uSize;

void main()
{
    // ...
    
    gl_PointSize = uSize * uPixelRatio;
    // ...
}

并添加一个 tweak 来控制它:

gui.add(firefliesMaterial.uniforms.uSize, 'value').min(0).max(500).step(1).name('firefliesSize')
lesson demo video preview

萤火虫图案

是时候把那些红色方块变成更好看的萤火虫了。我们要创建一些闪亮的点来表示它们。

我们本可以使用纹理,而且那样性能更好。但为了这节课的目的,也为了对图案有更多控制,我们要在 fragment shader 中绘制它。

把粒子大小提高到大约 200。首先用 gl_PointCoord 显示点的 UV 坐标:

void main()
{
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}

我们要用这些坐标在每个中心绘制一个闪亮的点。

首先,我们知道需要处理包含到坐标中心距离的那个值。

创建一个 distanceToCenter 变量,并计算中心(vec2(0.5))与 gl_PointCoord 之间的距离:

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}

把这个 distanceToCenter 变量用作 gl_FragColor 的 alpha:

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));

    gl_FragColor = vec4(1.0, 1.0, 1.0, distanceToCenter);
}

粒子全是白色的,尽管 alpha 本应发生变化。这是因为我们忘了把材质的 transparent 属性设为 true:

const firefliesMaterial = new THREE.ShaderMaterial({
    transparent: true,
    // ...
})

现在我们可以通过 alpha 看到我们的图案了。

接下来,我们尝试创建一个闪亮的图案。为此,我们可以从一个非常小的值开始,然后把它除以 distanceToCenter。

创建一个 strength 变量,用 0.05 除以 distanceToCenter,并把它传给 gl_FragColor:

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    float strength = 0.05 / distanceToCenter;

    gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
}

它们看起来不错,但遗憾的是,我们能看到边缘。这是因为这个公式随着距离会变得非常低,但永远不会达到 0:

为了解决这个问题,我们可以减去一个较小的值,但要足够大,以确保结果在到达边缘之前降到 0.0 以下:

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    float strength = 0.05 / distanceToCenter - 0.1;

    gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
}

现在萤火虫看起来像一个个小小的光点。

缩放随机性

在创建有机的东西时,加入随机性总是好的。到目前为止,所有萤火虫的大小都一样。

我们要给每一只发送不同的缩放倍数。

创建一个 aScale 属性,并用 Math.random() 填充随机值。每个顶点只需要 1 个值,所以要确保 Float32Array 的长度正确,并把 BufferAttribute 的第二个参数设为 1:

const scaleArray = new Float32Array(firefliesCount)

for(let i = 0; i < firefliesCount; i++)
{
    // ...

    scaleArray[i] = Math.random()
}

// ...
firefliesGeometry.setAttribute('aScale', new THREE.BufferAttribute(scaleArray, 1))

我们可以在 vertex shader 中获取该属性,并在 gl_PointSize 公式中使用它:

// ...

attribute float aScale;

void main()
{
    // ...
    
    gl_PointSize = uSize * aScale * uPixelRatio;
    gl_PointSize *= (1.0 / - viewPosition.z);
}

现在萤火虫有了不同的大小。

混合

为了让它们更加闪亮、看起来像由光构成的光点,把材质的 blending 属性设为 THREE.AdditiveBlending:

const firefliesMaterial = new THREE.ShaderMaterial({
    blending: THREE.AdditiveBlending,
    // ...
})

深度写入

在某些特定角度,你会注意到一些裁剪问题:一个粒子似乎遮住了它后面的粒子。即使 alpha 本应被设为一个很低的值,这种情况仍然存在。

为了解决这个问题,我们可以通过把材质上的 depthWrite 属性设为 false 来禁用深度写入:

const firefliesMaterial = new THREE.ShaderMaterial({
    depthWrite: false,
    // ...
})
lesson demo video preview

漂浮动画

最后,我们希望萤火虫上下漂浮,让它们更有生气。

因为我们将在 vertex shader 中做动画,所以需要把时间传给它。

创建一个 uTime uniform,并在 tick 函数中使用 elapsedTime 变量更新它:

const firefliesMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uTime: { value: 0 },
        // ...
    },
    // ...
})

// ...
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update materials
    firefliesMaterial.uniforms.uTime.value = elapsedTime

    // ...
}

在 vertex shader 中获取 uTime uniform,并用 sin() 更新 modelPosition.y:

uniform float uTime;

// ...

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    modelPosition.y += sin(uTime);

    // ...
}
lesson demo video preview

这是个不错的开始,但我们需要加入一些随机性。首先,让它们不同步。

我们本可以发送一个新属性,但取而代之(因为它们只在 y 轴上移动),我们可以用 x 轴来偏移 sin() 中的值:

// ...

void main()
{
    // ...
    modelPosition.y += sin(uTime + modelPosition.x * 100.0);

    // ...
}
lesson demo video preview

动画看起来更随机了,但我们还需要随机化并减小振幅。

我们可以使用 aScale,让最小的萤火虫移动得更少。我们还可以乘以一个小值来减小整体振幅:

// ...

void main()
{
    // ...
    modelPosition.y += sin(uTime + modelPosition.x * 100.0) * aScale * 0.2;

    // ...
}
lesson demo video preview

萤火虫就到这里了,但你可以更进一步,比如给 lil-gui 添加更多 tweak、改进图案,或者用 uniform 发送一个颜色。

传送门

我们场景的最后一块是传送门。我们要绘制一些比这个单一颜色更好看的东西。

自定义着色器材质

我们可以先把传送门的材质换成 ShaderMaterial。

在 /src/shaders/ 文件夹中,创建一个 portal/ 文件夹。

在那个 portal/ 文件夹中,创建一个 vertex.glsl 文件和一个 fragment.glsl 文件。

同样,你可以试着自己编写这些基础的 vertex 和 fragment shader。

以下是 /src/shaders/portal/vertex.glsl 的代码:

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectionPosition = projectionMatrix * viewPosition;

    gl_Position = projectionPosition;
}

以下是 /src/shaders/portal/fragment.glsl 的代码:

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

我们把顶点放到它们应该在的位置,并把颜色设置为单一的红色。

现在我们可以把这些 shader 导入到脚本中:

import portalVertexShader from './shaders/portal/vertex.glsl'
import portalFragmentShader from './shaders/portal/fragment.glsl'

把 MeshBasicMaterial 替换为一个带有 vertexShader 和 fragmentShader 属性的 ShaderMaterial:

const portalLightMaterial = new THREE.ShaderMaterial({
    vertexShader: portalVertexShader,
    fragmentShader: portalFragmentShader
})

你应该会得到一扇红色的传送门,它与场景的其余部分不搭,但我们会解决这个问题。

就像在 Shader Patterns 那一课中一样,我们要使用 UV 坐标在传送门上绘制。

使用一个 vUv varying,把 UV 坐标从 vertex shader 传递到 fragment shader。

vertex shader:

varying vec2 vUv;

void main()
{
    // ...

    vUv = uv;
}

fragment shader:

varying vec2 vUv;

void main()
{
    gl_FragColor = vec4(vUv, 1.0, 1.0);
}

如果你使用的是自己的模型,却没有得到这个渐变,可能是因为你的传送门没有正确地展开 UV。

修正 UV 坐标

为了解决这个问题,打开你的 .blend 文件:

只选择传送门:

进入 Edit Mode,选中所有面,并在 UV Editor 中查看 UV 是如何展开的:

在这里,展开得很好,但你的结果可能不同,甚至可能什么都没有。

不用担心 UV Editor 背景中的烘焙纹理。我们不需要关心它。我们只想展开这个圆盘形的传送门,让它填满整张 UV 图。

为此,在仍处于 UV Editor 且选中所有面的情况下,按 U 并选择 Unwrap。

就这样。我们并不真的在意 UV 的旋转,因为我们要绘制的图案是圆形的。

退出 Edit Mode,选择合并后的对象以及所有发光部分(emission)。然后,把所有内容导出为 portal.glb,放到 /static/ 文件夹中:

刷新你的场景,现在你应该得到相同的渐变:

如果渐变的方向不同,也没关系。我们的图案将是圆形的。

Perlin 噪声

现在到了有趣的部分。只要找到正确的公式,你可以在传送门中绘制任何你想要的东西。

接下来的步骤会直接通向一个令人满意的结果。但显然,这是一个尝试了很多东西、直到最终看起来不错才结束的漫长过程。如果你自己做的时候花了几个小时,不要沮丧。这全靠时间和练习。

我们将从经典的 Perlin 噪声开始。在这个例子中,我们将使用 3D 版本,这样我们就可以用时间对第三个值做动画,从而给噪声带来变化。

把下面这个由 Stefan Gustavson 编写的函数(可在 这个 gist 中找到)添加到你的 src/shaders/portal/fragment.glsl 文件中:

//	Classic Perlin 3D Noise 
//	by Stefan Gustavson
//
vec4 permute(vec4 x){ return mod(((x*34.0)+1.0)*x, 289.0); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
vec3 fade(vec3 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }

float cnoise(vec3 P)
{
    vec3 Pi0 = floor(P); // Integer part for indexing
    vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
    Pi0 = mod(Pi0, 289.0);
    Pi1 = mod(Pi1, 289.0);
    vec3 Pf0 = fract(P); // Fractional part for interpolation
    vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
    vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
    vec4 iy = vec4(Pi0.yy, Pi1.yy);
    vec4 iz0 = Pi0.zzzz;
    vec4 iz1 = Pi1.zzzz;

    vec4 ixy = permute(permute(ix) + iy);
    vec4 ixy0 = permute(ixy + iz0);
    vec4 ixy1 = permute(ixy + iz1);

    vec4 gx0 = ixy0 / 7.0;
    vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
    gx0 = fract(gx0);
    vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
    vec4 sz0 = step(gz0, vec4(0.0));
    gx0 -= sz0 * (step(0.0, gx0) - 0.5);
    gy0 -= sz0 * (step(0.0, gy0) - 0.5);

    vec4 gx1 = ixy1 / 7.0;
    vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
    gx1 = fract(gx1);
    vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
    vec4 sz1 = step(gz1, vec4(0.0));
    gx1 -= sz1 * (step(0.0, gx1) - 0.5);
    gy1 -= sz1 * (step(0.0, gy1) - 0.5);

    vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
    vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
    vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
    vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
    vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
    vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
    vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
    vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

    vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
    g000 *= norm0.x;
    g010 *= norm0.y;
    g100 *= norm0.z;
    g110 *= norm0.w;
    vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
    g001 *= norm1.x;
    g011 *= norm1.y;
    g101 *= norm1.z;
    g111 *= norm1.w;

    float n000 = dot(g000, Pf0);
    float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
    float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
    float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
    float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
    float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
    float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
    float n111 = dot(g111, Pf1);

    vec3 fade_xyz = fade(Pf0);
    vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
    vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
    float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); 
    
    return 2.2 * n_xyz;
}

在这个过程结束时,我们将显示两种颜色的混合,但就目前而言,我们只专注于一个变量。

创建一个 strength 变量,并使用我们刚刚添加的 cnoise 函数。

这个函数需要一个 vec3 作为参数。目前,我们会把 vUv 用作 x 和 y,稍后会把时间变量用于 z:

// ...

void main()
{
    float strength = cnoise(vec3(vUv * 5.0, 0.0));

    gl_FragColor = vec4(vUv, 1.0, 1.0);
}

如果没有错误,把 strength 传给 gl_FragColor:

// ...

void main()
{
    float strength = cnoise(vec3(vUv * 5.0, 0.0));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

这是个不错的开始,我们可以看到 Perlin 噪声了。

在你的 JavaScript 文件中,创建一个 uTime uniform,并像我们对萤火虫所做的那样,在 tick 函数中更新它:

const portalLightMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uTime: { value: 0 }
    },
    // ...
})

// ...

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update materials
    firefliesMaterial.uniforms.uTime.value = elapsedTime
    portalLightMaterial.uniforms.uTime.value = elapsedTime

    // ...
}

tick()

在 fragment shader 中获取它,并把它作为我们传给 cnoise 的 vec3 的第三个值:

uniform float uTime;

// ...

void main()
{
    // Perlin noise
    float strength = cnoise(vec3(vUv * 5.0, uTime));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}
lesson demo video preview

我们已经能感受到一些《星际之门》的氛围了。

这个经典的 Perlin 噪声不错,但通过置换 UV 坐标,我们可以得到更好的效果。我们将基于最初的 UV 坐标创建新的 UV 坐标,但会用另一个 Perlin 噪声来偏移 x 和 y:

// ...

void main()
{
    // Displace the UV
    vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime));

    // Perlin noise
    float strength = cnoise(vec3(displacedUv * 5.0, uTime));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}
lesson demo video preview

动画很棒,但有点太快了。为了放慢它并获得一些有机的随机性,把 uTime 乘以小的值,让 displacedUv 和 strength 使用不同的值:

// ...

void main()
{
    // Displace the UV
    vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime * 0.1));

    // Perlin noise
    float strength = cnoise(vec3(displacedUv * 5.0, uTime * 0.2));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}
lesson demo video preview

外部辉光

我们走在正确的道路上,但现在主要关心的是传送门本身与周围砖块之间的衔接处。它们之间的衔接处应该是完全白色的。

我们需要创建一个外部辉光。为此,我们先求出到中心的距离,并把结果显示在 gl_FragColor 中:

// ...

void main()
{
    // ...

    // Outer glow
    float outerGlow = distance(vUv, vec2(0.5));

    gl_FragColor = vec4(outerGlow, outerGlow, outerGlow, 1.0);
}

这个渐变正是我们想要的,但我们需要把边缘处的值推高,以确保到达边缘时渐变是完全白色的。为此,不断地对值进行相乘和偏移,直到你满意为止:

// ...

void main()
{
    // ...

    // Outer glow
    float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;

    gl_FragColor = vec4(outerGlow, outerGlow, outerGlow, 1.0);
}

现在我们可以把 outerGlow 加到 strength 上,并把 strength 传给 gl_FragColor:

// ...

void main()
{
    // ...

    // Outer glow
    float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;
    strength += outerGlow;

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

我们大致得到了想要的结果,但遗憾的是,边缘并不是完美的白色。这是因为 Perlin 噪声没有被限制在 0.0 到 1.0 之间。这意味着该值实际上可能低于 -1.0 或高于 +1.0。但我们不打算对此做任何处理,因为下一步会解决它。

目前,我们有一个非常平滑的图案,但实际上它太光滑了。我们想给它增加一些锐度。

Step 函数

为此,我们可以对 strength 应用一个 step() 函数。

step() 函数的第一个参数是一个阈值(也叫 edge)。当第二个参数的值高于这个阈值时,你会得到 1.0;如果值低于这个阈值,你会得到 0.0。

你可以为这个阈值尝试不同的值:

// ...

void main()
{
    // ...

    // Apply cool step
    strength = step(- 0.2, strength);

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}
lesson demo video preview

我们得到了一个不错的锐利结果,但现在又太锐利了。

我们不是用那个 step() 函数替换 strength,而是把它加到最初的 strength 上。我们还可以把它乘以一个较低的值,稍微减弱 step 的效果:

// ...

void main()
{
    // ...

    // Apply cool step
    strength += step(- 0.2, strength) * 0.8;

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}
lesson demo video preview

这就是我们的最终图案。如你所见,边缘是完美的白色,我们在平滑与锐利之间取得了很好的平衡。

颜色

在这个例子中,我们对黑白的结果很满意,因为我们的传送门光最初就是白色的。但是,万一你选择了一个不同的传送门颜色,我们还是加上颜色吧。

在 JavaScript 文件中,添加一个 uColorStart 和一个 uColorEnd uniform,使用红色和蓝色。使用 Color 类:

const portalLightMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uTime: { value: 0 },
        uColorStart: { value: new THREE.Color(0xff0000) },
        uColorEnd: { value: new THREE.Color(0x0000ff) }
    },
    // ...
})

在 fragment shader 中获取这些 uniform,用 strength 把它们 mix() 到一个 color 变量中,并把它传给 gl_FragColor:

uniform float uTime;
uniform vec3 uColorStart;
uniform vec3 uColorEnd;

// ...

void main()
{
    // ...

    // Final color
    vec3 color = mix(uColorStart, uColorEnd, strength);

    gl_FragColor = vec4(color, 1.0);
}

你应该会得到红色和蓝色之间不错的混合。

让我们把这些颜色添加到我们的 lil-gui。和往常一样,我们把他们作为 debugObject 的属性添加,并监听变化以相应地更新 uniform:

debugObject.portalColorStart = '#ff0000'
debugObject.portalColorEnd = '#0000ff'

gui
    .addColor(debugObject, 'portalColorStart')
    .onChange(() =>
    {
        portalLightMaterial.uniforms.uColorStart.value.set(debugObject.portalColorStart)
    })
    
gui
    .addColor(debugObject, 'portalColorEnd')
    .onChange(() =>
    {
        portalLightMaterial.uniforms.uColorEnd.value.set(debugObject.portalColorEnd)
    })
lesson demo video preview

现在你可以调整颜色了。

但有一个问题。使用某些颜色,比如从深蓝到橙色时,你会注意到边缘会变成黄色,而且我们也会失去之前的锐度:

这是因为 strength 实际上会低于 0.0 或高于 1.0,导致颜色被外推。对于黑白来说问题不大,但换成其他一些颜色就会变得很乱。

为了解决这个问题,我们需要使用 clamp(...) 函数来确保该值被限制在 0.0 和 1.0 之间:

void main()
{
    // ...

    // Clamp the value
    strength = clamp(strength, 0.0, 1.0);

    // ...
}

总之,如果你使用的是起始项目中的模型,你大概应该坚持用黑白。

别忘了在 uniform 的初始值中使用 debugObject.portalColorStart 和 debugObject.portalColorEnd:

debugObject.portalColorStart = '#000000'
debugObject.portalColorEnd = '#ffffff'

// ...
    
const portalLightMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uColorStart: { value: new THREE.Color(debugObject.portalColorStart) },
        uColorEnd: { value: new THREE.Color(debugObject.portalColorEnd) },
        // ...
    },
    // ...
})

更进一步

我们的传送门场景就到这里了。现在是添加更多 tweak 和更多细节的好时机。

你当然可以尝试使用你学到的所有建模和烘焙流程,从零开始创建你自己的场景。慢慢来,确保拥有合适的几何体和干净的 UV 坐标。别犹豫,在 Twitter 或 Discord 服务器上分享你的成果。